А/Б-тестирование для интернет-магазина¶

Выпускной проект Яндекс Практикума. Часть II

Цель: оценить, повлияло ли внедрение улучшенной рекомендательной системы на метрики продаж.

Данные:

  • данные пользователей, зарегистрировавшихся с 7 до 21 декабря 2020 года,
  • действия новых пользователей в период с 7 декабря 2020 по 4 января,
  • таблица участников тестов,
  • календарь маркетинговых событий на 2020 год.

Заказчик: отдел маркетинга.

Задачи:

  • проверка данных на соответствие ТЗ,
  • EDA: сравнение групп А и Б,
  • анализ воронки продаж в разрезе сопоставления групп,
  • статистический тест пропорций для групп А и Б на каждом этапе воронки.

Оглавление

  • 1 Обзор данных
    • 1.1 сохранение переменных
    • 1.2 тип данных
    • 1.3 пропуски
    • 1.4 дубликаты
    • 1.5 вывод
  • 2 Проверка данных на соответствие ТЗ
    • 2.1 даты
    • 2.2 регион
    • 2.3 конкурирующий тест
    • 2.4 лайфтайм
    • 2.5 маркетинговые события
    • 2.6 распределение по группам А и Б
    • 2.7 вывод
  • 3 EDA
    • 3.1 пользователи без действий
    • 3.2 сравнение групп А и Б
    • 3.3 количество событий на пользователя
    • 3.4 воронка событий
    • 3.5 вывод
  • 4 Статистический анализ
    • 4.1 вывод
  • 5 Итог

1 Обзор данных¶

In [1]:
import math as mth
import numpy as np
import pandas as pd
import seaborn as sns
import datetime as dt 
import scipy.stats as stats
import plotly.express as px

from datetime import datetime
from matplotlib import pyplot as plt
from plotly import graph_objects as go

pd.set_option('mode.chained_assignment', None)

1.1 сохранение переменных¶

Откроем датасеты и сохраним данные в переменных.

In [2]:
events, marketing, users, tests = (
    pd.read_csv('final_ab_events.csv'),
    pd.read_csv('ab_project_marketing_events.csv'),
    pd.read_csv('final_ab_new_users.csv'),
    pd.read_csv('final_ab_participants.csv')
    )
    
display(events.head())
display(marketing.head())
display(users.head())
tests.head()
user_id event_dt event_name details
0 E1BDDCE0DAFA2679 2020-12-07 20:22:03 purchase 99.99
1 7B6452F081F49504 2020-12-07 09:22:53 purchase 9.99
2 9CD9F34546DF254C 2020-12-07 12:59:29 purchase 4.99
3 96F27A054B191457 2020-12-07 04:02:40 purchase 4.99
4 1FD7660FDF94CA1F 2020-12-07 10:15:09 purchase 4.99
name regions start_dt finish_dt
0 Christmas&New Year Promo EU, N.America 2020-12-25 2021-01-03
1 St. Valentine's Day Giveaway EU, CIS, APAC, N.America 2020-02-14 2020-02-16
2 St. Patric's Day Promo EU, N.America 2020-03-17 2020-03-19
3 Easter Promo EU, CIS, APAC, N.America 2020-04-12 2020-04-19
4 4th of July Promo N.America 2020-07-04 2020-07-11
user_id first_date region device
0 D72A72121175D8BE 2020-12-07 EU PC
1 F1C668619DFE6E65 2020-12-07 N.America Android
2 2E1BF1D4C37EA01F 2020-12-07 EU PC
3 50734A22C0C63768 2020-12-07 EU iPhone
4 E1BDDCE0DAFA2679 2020-12-07 N.America iPhone
Out[2]:
user_id group ab_test
0 D1ABA3E2887B6A73 A recommender_system_test
1 A7A3664BD6242119 A recommender_system_test
2 DABC14FDDFADD29E A recommender_system_test
3 04988C5DF189632E A recommender_system_test
4 482F14783456D21B B recommender_system_test

1.2 тип данных¶

Выведем информацию о датасетах, проверим типы данных в столбцах.

In [3]:
display(events.info())
display(marketing.info())
display(users.info())
tests.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 440317 entries, 0 to 440316
Data columns (total 4 columns):
 #   Column      Non-Null Count   Dtype  
---  ------      --------------   -----  
 0   user_id     440317 non-null  object 
 1   event_dt    440317 non-null  object 
 2   event_name  440317 non-null  object 
 3   details     62740 non-null   float64
dtypes: float64(1), object(3)
memory usage: 13.4+ MB
None
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 14 entries, 0 to 13
Data columns (total 4 columns):
 #   Column     Non-Null Count  Dtype 
---  ------     --------------  ----- 
 0   name       14 non-null     object
 1   regions    14 non-null     object
 2   start_dt   14 non-null     object
 3   finish_dt  14 non-null     object
dtypes: object(4)
memory usage: 576.0+ bytes
None
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 61733 entries, 0 to 61732
Data columns (total 4 columns):
 #   Column      Non-Null Count  Dtype 
---  ------      --------------  ----- 
 0   user_id     61733 non-null  object
 1   first_date  61733 non-null  object
 2   region      61733 non-null  object
 3   device      61733 non-null  object
dtypes: object(4)
memory usage: 1.9+ MB
None
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 18268 entries, 0 to 18267
Data columns (total 3 columns):
 #   Column   Non-Null Count  Dtype 
---  ------   --------------  ----- 
 0   user_id  18268 non-null  object
 1   group    18268 non-null  object
 2   ab_test  18268 non-null  object
dtypes: object(3)
memory usage: 428.3+ KB

Приведем даты в таблице events к datetime и создадим столбец с датой.

In [4]:
events['event_dt'] = pd.to_datetime(events['event_dt'])
events['date'] = events['event_dt'].dt.date
users['first_date'] = pd.to_datetime(users['first_date']).dt.date
marketing['start_dt'] = pd.to_datetime(marketing['start_dt']).dt.date
marketing['finish_dt'] = pd.to_datetime(marketing['finish_dt']).dt.date

1.3 пропуски¶

Выведем данные о пропусках.

In [5]:
display(events.isna().sum())
display(marketing.isna().sum())
display(users.isna().sum())
tests.isna().sum()
user_id            0
event_dt           0
event_name         0
details       377577
date               0
dtype: int64
name         0
regions      0
start_dt     0
finish_dt    0
dtype: int64
user_id       0
first_date    0
region        0
device        0
dtype: int64
Out[5]:
user_id    0
group      0
ab_test    0
dtype: int64

Пропуски только в одном столбце details — рассмотрим подробнее.

In [6]:
print('Доля пропусков в столбце «details»:', round(events['details'].isna().sum() / events['user_id'].count(), 2))
Доля пропусков в столбце «details»: 0.86

Пропуски столбца составляют 86%. Смотрим, привязаны ли имеющиеся данные к какому-то значению столбца event_name.

In [7]:
(
    events
    .groupby('event_name')
    .agg(count=('user_id', 'count'))
    .merge(events[~(events['details'].isna())]
           .groupby('event_name')
           .agg(details=('user_id', 'count')), how='outer', on='event_name')
)
Out[7]:
count details
event_name
login 189552 NaN
product_cart 62462 NaN
product_page 125563 NaN
purchase 62740 62740.0

Видим, что все значения столбца относятся только к покупкам и не связаны с другими действиями. Таким образом, подтверждается информация ТЗ: в столбце находятся дополнительные данные о событии, например, для purchase — стоимость покупки. Выведем значения столбца.

In [8]:
events['details'].value_counts()
Out[8]:
4.99      46362
9.99       9530
99.99      5631
499.99     1217
Name: details, dtype: int64

Принимаем решения оставить столбец как есть, эти данные пригодятся в дальнейшем для анализа равномерности групп.

1.4 дубликаты¶

Проверим датасеты на явные дубликаты.

In [9]:
display(events.duplicated().sum())
display(marketing.duplicated().sum())
display(users.duplicated().sum())
tests.duplicated().sum()
0
0
0
Out[9]:
0

Проверим датасеты на дублирующихся пользователей.

In [10]:
display(users['user_id'].duplicated().sum())
tests['user_id'].duplicated().sum()
0
Out[10]:
1602

В данных о новых пользователях все пользователи уникальны. В датасете об участниках тестов есть дублирующиеся пользователи. Учтем этот факт на следующем этапе.

1.5 вывод¶

  1. Загружено и сохранено в переменные 4 датасета.
  2. В датасете events создан дополнительный столбец с датой.
  3. Пропуски только в одном столбце в одном датасете, не критичны для анализа.
  4. Полных дубликатов нет. Среди участников тестов есть дублирующиеся пользователи.

назад в оглавление

2 Проверка данных на соответствие ТЗ¶

2.1 даты¶

Создадим переменную с участниками только нашего теста.

In [11]:
data = tests[tests['ab_test'] == 'recommender_system_test']
print('Количество пользователей теста:', data['user_id'].count())
Количество пользователей теста: 6701

Посмотрим на минимальную и максимальную даты в данных о новых пользователях.

In [12]:
print('Начало:', users['first_date'].min())
print('Окончание:', users['first_date'].max())
Начало: 2020-12-07
Окончание: 2020-12-23

Есть лишние данные о пользователях за 22 и 23 декабря, будем это учитывать. Зададим переменные с датами старта и окончания набора в тест, а также с датой окончания самого теста.

In [13]:
start = users['first_date'].min()
finish = start + dt.timedelta(days=14)
end = finish + dt.timedelta(days=14)

Присоединим данные из датасета с новыми пользователями и заодно проверим, все ли пользователи нашего теста зарегистрировались в нужный промежуток времени.

In [14]:
data = (
    data
    .merge(
        users
        .query('@start <= first_date <= @finish'), how='inner'
    )
)
data.rename(columns={'first_date': 'reg_date'}, inplace = True)
data['user_id'].count()
Out[14]:
6701

Все пользователи зарегистрировались в нужный период.

Проверим даты датасета с событиями.

In [15]:
print('Начало:', events['date'].min())
print('Окончание:', events['date'].max())
Начало: 2020-12-07
Окончание: 2020-12-30

Дата старта записи событий соответствует началу теста. Дата окончания записи событий не соответствует ТЗ, не хватает нескольких дней до 4 января. Это означает, что часть пользователей не прожила необходимые 14 дней. Оценим количество пользователей, которые не прожили полный цикл.

In [16]:
last_full = events['date'].max() - dt.timedelta(days=14)

print('Количество пользователей, не проживших 14 дней:', data[data['reg_date'] > last_full]['user_id'].count())
print('Доля пользователей, не проживших 14 дней:', 
      round(data[data['reg_date'] > last_full]['user_id'].count() /
      data['user_id'].count(), 2))
Количество пользователей, не проживших 14 дней: 2387
Доля пользователей, не проживших 14 дней: 0.36

Доля таких пользователей очень большая. Пока оставим все как есть, учтем при анализе лайфтайма.

2.2 регион¶

Проверим информацию о регионе пользователей.

In [17]:
data['region'].value_counts()
Out[17]:
EU           6351
N.America     223
APAC           72
CIS            55
Name: region, dtype: int64
In [18]:
not_EU = data.query('not region == "EU"')['user_id'].count()
print('Количество пользователей не из Европы, отобранных в тест:', not_EU)
print('Доля в датасете:', round(not_EU / data['user_id'].count(), 2))
Количество пользователей не из Европы, отобранных в тест: 350
Доля в датасете: 0.05

Часть пользователей не подходит по региону. Оставляем только пользователей из Европы и проверяем, составляют ли они 15% от всех новых пользователей из Европы.

In [19]:
data = data[data['region'] == 'EU']

data['user_id'].count() / (
    users
    .query('region == "EU" and @start <= first_date <= @finish')['user_id'].count()
)
Out[19]:
0.15

По этому критерию данные пока соответствуют.

2.3 конкурирующий тест¶

Выведем количество наших пользователей и дубликаты.

In [20]:
print('Количество пользователей:', data['user_id'].count())
print('Дубликатов внутри теста:', data['user_id'].duplicated().sum())
Количество пользователей: 6351
Дубликатов внутри теста: 0

Внутри нужного теста дублирующихся пользователей нет, значит все дубликаты, обнаруженные на этапе обзора данных, это пересечения пользователей между двумя тестами. Оценим их распределение по группам прежде всего конкурирующего теста.

In [21]:
(
    tests[tests['user_id'].duplicated(keep=False)]
    .groupby(['ab_test', 'group'])
    .agg(count=('user_id', 'count'))
)
Out[21]:
count
ab_test group
interface_eu_test A 819
B 783
recommender_system_test A 921
B 681

На пользователей, попавших в группу А конкурирующего теста, никакого воздействия не оказывалось, поэтому их можно не отсеивать. Посмотрим, какую долю наших пользователей составляют пользователи группы Б конкурирующего теста.

In [22]:
other_B = (
    tests[
     (tests['user_id'].duplicated(keep=False)) &
     (tests['ab_test'] == 'interface_eu_test') & 
     (tests['group'] == 'B')]
)

print(round(other_B['user_id'].count() / data['user_id'].count(), 2))
0.12

12% это довольно много: при удалении пользователей станет заметно меньше 6000. Посмотрим, как они распределились по группам нашего теста.

In [23]:
other_B_in_data = (
    data[data['user_id'].isin(other_B['user_id'])]
    .groupby('group')
    .agg(dupl=('user_id', 'count'))
    .merge(
            data
        .groupby('group')
        .agg(count=('user_id', 'count')), on='group'
    )
)
other_B_in_data['share'] = other_B_in_data['dupl'] / other_B_in_data['count']
other_B_in_data
Out[23]:
dupl count share
group
A 439 3634 0.120804
B 344 2717 0.126610

Распределение по группам нашего теста примерно одинаковое. Примем решение условно считать влияние другого теста на обе наши группы одинаковым и оставить дубликаты с конкурирующим тестом.

2.4 лайфтайм¶

Добавим в датасет с пользователями информацию о событиях и рассчитаем лайфтайм.

In [24]:
data = (
    data
    .merge(events, how='left')
    .drop(['ab_test', 'event_dt'], axis=1)
)

data['lifetime'] = data['date'] - data['reg_date']
data['lifetime'] = data['lifetime'].dt.days
data.rename(columns={'date': 'ev_date'}, inplace = True)

Посмотрим, на какой день обычно пользователи совершают события.

In [25]:
plt.figure(figsize=(7, 4))
sns.histplot(data['lifetime'], binwidth=1)
plt.title('Распределений событий по лайфтайму')
plt.ylabel('кол-во клиентов')
plt.xlabel('лайфтайм')
plt.show()

Мы видим, что большинство действий пользователи совершают в первую неделю лайфтайма. Посмотрим на перцентили.

In [26]:
perc_lt = np.nanpercentile(data['lifetime'], [90, 95, 99])
perc_lt
Out[26]:
array([ 9., 12., 18.])

90% событий происходит в первые 9 дней лайфтайма, этот срок совпадает с наиболее коротким сроком жизни наших пользователей, набранных 21 декабря (0-й день) и доживших до 30 декабря (9-й день). Принимаем окончательное решение оставить пользователей, не проживших полный лайфтайм.

Отсекаем события, совершенные после 14 дня.

In [27]:
data = data[~(data['lifetime'] > 14)]
data['user_id'].nunique()
Out[27]:
6351

Оставшееся после отсечения количество уникальных пользователей не изменилось, то есть среди наших пользователей не было таких, кто начал совершать события только после 14 дня.

Оценим количество пользователей, не совершавших никаких событий.

In [28]:
print('Количество пользователей, не совершавших событий:', data['event_name'].isna().sum())
print('Доля от общего числа:', round(data['event_name'].isna().sum() / data['user_id'].nunique(), 2))
Количество пользователей, не совершавших событий: 2870
Доля от общего числа: 0.45

2.5 маркетинговые события¶

Оценим возможное влияние маркетинговых событий на тест. Выведем информацию об акциях проходивших а) в Европе и б) полностью или частично в сроки нашего теста.

In [29]:
marketing.query('regions.str.contains("EU") and start_dt <= @end and finish_dt >= @start')
Out[29]:
name regions start_dt finish_dt
0 Christmas&New Year Promo EU, N.America 2020-12-25 2021-01-03

Есть одна акция, проходившая в Европе в дни теста. Необходимо оценить ее возможное влияние на пользователей. Строим график распределения событий по календарным дням.

In [30]:
plt.figure(figsize=(10, 4))
sns.histplot(
    data.query('not ev_date.isna()')['ev_date'], 
    binwidth=1
            )
plt.title('Распределений событий по календарным дням')
plt.ylabel('кол-во событий')
plt.xlabel('даты')
plt.show()

На графике не видим всплеска событий в дни проведения акции, т. е. с 25 декабря. Принимаем решение условно считать воздействие акции на пользователей незначительным.

2.6 распределение по группам А и Б¶

Выведем распределение пользователей в тесте по группам.

In [31]:
groups = data.groupby('group', as_index=False).agg(size=('user_id', 'nunique'))
groups['share'] = round(groups['size'] / data['user_id'].nunique(), 2)
groups
Out[31]:
group size share
0 A 3634 0.57
1 B 2717 0.43

57% в группе А и 43% в группе Б — распределение не совсем равномерное.

2.7 вывод¶

Еще раз выведем данные на соответствие критериям.

In [32]:
print('Общее количество пользователей в тесте:', data['user_id'].nunique())
print('Дата начала регистрации:', data['reg_date'].min())
print('Дата окончания регистрации:', data['reg_date'].max())
print('Регион пользователей:', data['region'].value_counts())
print('Доля пользователей в тесте от всех новых пользователей из Европы:', 
      data['user_id'].nunique() / users.query('region == "EU" and @start <= first_date <= @finish')['user_id'].count()
     )
print('Начало совершения событий:', data.query('not ev_date.isna()')['ev_date'].min())
print('Окончание совершения событий:', data.query('not ev_date.isna()')['ev_date'].max())
print(f'Пользователей в группе А:', data.query('group == "A"')['user_id'].nunique())
print('Пользователей в группе Б:', data.query('group == "B"')['user_id'].nunique())
Общее количество пользователей в тесте: 6351
Дата начала регистрации: 2020-12-07
Дата окончания регистрации: 2020-12-21
Регион пользователей: EU    25698
Name: region, dtype: int64
Доля пользователей в тесте от всех новых пользователей из Европы: 0.15
Начало совершения событий: 2020-12-07
Окончание совершения событий: 2020-12-29
Пользователей в группе А: 3634
Пользователей в группе Б: 2717

Обращаем внимание, что дата окончания совершения событий уменьшилась на 1 день — значит, все действия, совершенные пользователями 30 декабря, были совершены позже 14 дня лайфтайма.

Соответствие данных техническому заданию:

  • + общее количество пользователей больше 6000,
  • + даты начала и окончания набора в тест соответствуют,
  • + аудитория — 15% пользователей из Европы,
  • - окончание совершения событий за несколько дней до полного лайфтайма последней когорты,
  • +/- распределение пользователей по группам не вполне равномерное,
  • - по первоначальному отбору в тест попало 350 пользователей из других регионов — 5% датасета,
  • - 45% пользователей в датасете не совершали никаких событий.

назад в оглавление

3 EDA¶

3.1 пользователи без действий¶

Посмотрим, какое количество пользователей в каждой группе вообще не совершало событий.

In [33]:
groups = (
    groups
    .merge(
        data
        .query('event_name.isna()')
        .groupby('group', as_index=False)
        .agg(no_events=('user_id', 'nunique')), how='left'
    )
)
groups['no_events_group_share'] = round(groups['no_events'] / groups['size'], 2)
groups['no_events_common_share'] = round(groups['no_events'] / data['user_id'].nunique(), 2)
groups
Out[33]:
group size share no_events no_events_group_share no_events_common_share
0 A 3634 0.57 1030 0.28 0.16
1 B 2717 0.43 1840 0.68 0.29

Пользователей, не совершавших никаких действий, в группе Б намного больше: 68% от размера группы и 29% от всех пользователей. Выведем даты их регистраций.

In [34]:
fig, axes = plt.subplots(1, 2, figsize=(15, 5), sharey=True, tight_layout=True)
plt.suptitle('Даты регистраций пользователей без событий', fontsize=18)

group_fig = ['A', 'B']

for y in range(len(group_fig)):
    sns.histplot(data=data[data['group'] == group_fig[y]].query('event_name.isna()'), x='reg_date', binwidth=1, ax=axes[y])
    axes[y].title.set_text(f'группа {group_fig[y]}')
    axes[y].tick_params(labelrotation=20)

В распределении по дням количество таких пользователей не отличается и для обеих групп не превышает 250 за 1 день. Разница в том, что в группе А эти пользователи появлялись только в первую неделю рассматриваемого периода, в группе Б — в течение всего периода.

Смотрим, каково соотношение пользователей без действий по группам.

In [35]:
dt = data.query('not event_name.isna()')
grps = dt.groupby('group', as_index=False).agg(size=('user_id', 'nunique'))
grps['share'] = round(grps['size'] / dt['user_id'].nunique(), 2)
print('Итоговое количество пользователей, совершавших события:', dt['user_id'].nunique())
grps
Итоговое количество пользователей, совершавших события: 3481
Out[35]:
group size share
0 A 2604 0.75
1 B 877 0.25

После удаления пользователей без событий распределение по группам стало еще более неравномерным.

3.2 сравнение групп А и Б¶

Исследуем группы А и Б на однородность.

In [36]:
fig, axes = plt.subplots(3, 2, figsize=(15, 15), sharey='row', tight_layout=True)

column_fig = ['reg_date', 'ev_date', 'lifetime']

for i in range(len(column_fig)):
    for y in range(len(group_fig)):
        sns.histplot(data=dt[dt['group'] == group_fig[y]], x=column_fig[i], binwidth=1, ax=axes[i, y])
        axes[i, y].title.set_text(f'{column_fig[i]}, группа {group_fig[y]}')
        axes[i, y].title.set_size(18)
        axes[i, y].tick_params(labelrotation=20)

В группе А значительный и устойчивый рост количества регистраций и количества событий на вторую неделю эксперимента (с 14 декабря). Пик количества событий 21 декабря, в последний день набора пользователей в тест.

У группы Б были лучше показатели по количеству событий в первую неделю эксперимента: количество событий в двух группах в этот период находится примерно на одном уровне, тогда как сама группа в 3 раза меньше.

Характер лайфтайма в обеих группах совпадает. Количество событий в первый день значительно превышает все остальные дни.

In [37]:
for item in ['device', 'event_name']:
    data_fig = (
        dt
        .groupby([item, 'group'])
        .agg(count=('user_id', 'count'))
        .reset_index()
    )
    
    data_fig['percent'] = ''
    
    total_A = dt.query('group == "A"')[item].count()
    total_B = dt.query('group == "B"')[item].count()

    for i in range(len(data_fig)):
        if data_fig['group'][i] == 'A':
            data_fig['percent'][i] = round(data_fig['count'][i] / total_A * 100, 2)
        else:
            data_fig['percent'][i] = round(data_fig['count'][i] / total_B * 100, 2)

    fig = px.bar(data_fig, 
                 x=item, 
                 y='count', 
                 color='group',
                 text='percent',
                 title=f'Относительное распределение {item} по группам, %'
                )
    fig.update_layout(barmode='stack', xaxis={'categoryorder':'total descending'})
    fig.show()
In [38]:
device = (
    dt
    .groupby(['device', 'group'])
    .agg(count=('user_id', 'count'))
    .reset_index()
)
device['percent'] = ''

device_A = dt.query('group == "A"')['device'].count()
device_B = dt.query('group == "B"')['device'].count()

for i in range(len(device)):
    if device['group'][i] == 'A':
        device['percent'][i] = round(device['count'][i] / device_A * 100, 2)
    else:
        device['percent'][i] = round(device['count'][i] / device_B * 100, 2)

fig = px.bar(device, 
             x='device', 
             y='count', 
             color='group',
             text='percent',
             title='Относительное распределение числа заходов с разных устройств, %'
            )
fig.update_layout(barmode='stack', xaxis={'categoryorder':'total descending'})
fig.show()

Выбор устройств внутри групп в целом похож, колебания не более 3%. Отметим, что пользователи внутри группы Б немного чаще заходят с мобильных устройств.

In [39]:
event = (
    dt
    .groupby(['event_name', 'group'])
    .agg(count=('user_id', 'count'))
    .reset_index()
)
event['percent'] = ''

event_A = dt.query('group == "A"')['event_name'].count()
event_B = dt.query('group == "B"')['event_name'].count()

for i in range(len(event)):
    if event['group'][i] == 'A':
        event['percent'][i] = round(event['count'][i] / event_A * 100, 2)
    else:
        event['percent'][i] = round(event['count'][i] / event_B * 100, 2)

fig = px.bar(event, 
             x='event_name', 
             y='count', 
             color='group',
             text='percent',
             title='Относительное распределение числа событий, %'
            )
fig.update_layout(barmode='stack', xaxis={'categoryorder':'total descending'})
fig.show()

Относительное распределения типов событий внутри групп тоже похоже. Но отметим, что пользователи группы Б все же немного чаще логинятся, чем просматривают страницы продуктов или совершают покупки.

In [40]:
details = dt.pivot_table(index='details', columns='group', values='event_name', aggfunc='count')
details['A_%'] = round(details['A'] / dt.query('group == "A"')['details'].count() * 100, 2)
details['B_%'] = round(details['B'] / dt.query('group == "B"')['details'].count() * 100, 2)
details
Out[40]:
group A B A_% B_%
details
4.99 1858 469 74.35 75.16
9.99 374 93 14.97 14.90
99.99 222 50 8.88 8.01
499.99 45 12 1.80 1.92

Суммы покупок в группах различаючтся также незначительно. Чуть чаще (1%) пользователи группы Б совершают дешевые покупки (4.99$), чем на большую сумму.

3.3 количество событий на пользователя¶

Выведем среднее и медианное количество событий на пользователя по группам.

In [41]:
events = dt.groupby(['user_id', 'group']).agg(events=('event_name', 'count'))
events.groupby('group').agg(mean=('events', 'mean'), median=('events', 'median'))
Out[41]:
mean median
group
A 6.903610 6.0
B 5.531357 4.0

И средний и медианный показатель в группе А лучше.

3.4 воронка событий¶

Посчитаем, сколько уникальных пользователей в каждой группе хоть раз совершали каждое из событий.

In [42]:
funnel = (
    dt
    .pivot_table(index='event_name', columns='group', values='user_id', aggfunc='nunique')
    .sort_values(by='A', ascending=False)
    .reset_index()
)

funnel
Out[42]:
group event_name A B
0 login 2604 876
1 product_page 1685 493
2 purchase 833 249
3 product_cart 782 244

Покупок у нас среди событий больше, чем просмотров корзины. (То есть, в магазине реализована функция быстрой покупки, позволяющая миновать этот этап). Зададим индексы для правильного отображения воронки и посчитаем конверсию на каждом этапе.

In [43]:
funnel = funnel.reindex([0, 1, 3, 2])

funnel['A_conv'] = funnel['A'] / funnel['A'][0]
funnel['B_conv'] = funnel['B'] / funnel['B'][0]

funnel['A_step'] = funnel['A'] / funnel['A'].shift(1)
funnel['B_step'] = funnel['B'] / funnel['B'].shift(1)


funnel
Out[43]:
group event_name A B A_conv B_conv A_step B_step
0 login 2604 876 1.000000 1.000000 NaN NaN
1 product_page 1685 493 0.647081 0.562785 0.647081 0.562785
3 product_cart 782 244 0.300307 0.278539 0.464095 0.494929
2 purchase 833 249 0.319892 0.284247 1.065217 1.020492

По таблице уже видим, что показатели конверсии в группе А лучше, но построим для наглядности график.

In [44]:
fig = go.Figure()

fig.add_trace(go.Funnel(
    name = 'Group A',
    y = funnel['event_name'],
    x = funnel['A'],
    textinfo = 'value+percent initial+percent previous',
    marker = {'color': 'tan'}
))

fig.add_trace(go.Funnel(
    name = 'Group B',
    y = funnel['event_name'],
    x = funnel['B'],
    textinfo = 'value+percent initial+percent previous',
    marker = {'color': 'silver'}
))

fig.update_layout(
    title='Конверсия пользователей по группам на каждом этапе воронки', 
    yaxis_title='событие',
    title_x = 0.5)
fig.show()

Показатели конверсии в группе А значительно лучше на этапе просмотра страницы продукта и несколько лучше на этапе просмотра корзины и этапе покупки. Ожидаемый результат улучшения каждой из метрик в группе Б не менее, чем на 10% не достигнут.

3.5 вывод¶

  1. После удаления пользователей без действий распределение по группам 75/25.

  2. В группе А резкий и устойчивый рост количества регистраций и количества событий на вторую неделю эксперимента (с 14 декабря). Группа Б без резких изменений внутри периода.

  3. У группы Б значительно лучше показатели по количеству событий в первую неделю эксперимента, которые нивелируются во вторую неделю.

  4. Характер лайфтайма в обеих группах совпадает. Количество событий в первый день значительно превышает все остальные дни.

  5. Пользователи группы Б немного чаще заходят с мобильных устройств, чем со стационарных.

  6. Пользователи группы Б немного чаще логинятся, чем просматривают страницы продуктов или совершают покупки.

  7. Суммы покупок в группах различаючтся незначительно. Чуть чаще (1%) пользователи группы Б совершают дешевые покупки (4.99), чем на большую сумму.

  8. Среднее и медианное количество событий на пользователя в группе А лучше.

  9. Показатели конверсии в группе А значительно лучше на этапе просмотра страницы продукта и несколько лучше на этапе просмотра корзины и этапе покупки. Ожидаемый результат улучшения каждой из метрик в группе Б не менее, чем на 10% не достигнут.

назад в оглавление

4 Статистический анализ¶

Проверим, находят ли статистические критерии разницу между группами А и Б на каждом этапе. Сформулируем гипотезы для каждого из этапов.

H_0: Конверсия в группе А и Б одинакова.
H_a: Конверсия в группе А и Б не одинакова.

Проведем проверку с помощью z-теста пропорций.

Примем пороговое значение alpha — 0,05.

Сравниваем между собой конверсию на трех этапах воронки: в просмотр страницы продукта, в просмотр корзины, в покупку. Несколько сравнений, проводимых на одних и тех же данных — это множественный тест, с каждой новой проверкой гипотезы растёт вероятность ошибки первого рода.

Поэтому с учетом множественности теста применим поправку Холма.

Проверим переменные с данными для проведения теста.

In [45]:
funnel
Out[45]:
group event_name A B A_conv B_conv A_step B_step
0 login 2604 876 1.000000 1.000000 NaN NaN
1 product_page 1685 493 0.647081 0.562785 0.647081 0.562785
3 product_cart 782 244 0.300307 0.278539 0.464095 0.494929
2 purchase 833 249 0.319892 0.284247 1.065217 1.020492

Зададим функцию для проведения z-теста.

In [46]:
def z_test(event_A, event_B, total_A, total_B):
    
    p1 = event_A / total_A
    p2 = event_B / total_B
    
    p_combined = (event_A + event_B) / (total_A + total_B)
    
    diff = p1 - p2
    
    z_value = diff / mth.sqrt(p_combined * (1 - p_combined) * (1/total_A + 1/total_B))
    distr = stats.norm(0, 1)  
    
    p_value = (1 - distr.cdf(abs(z_value))) * 2
    
    return p_value

Проведем сравнения для трех этапов воронки, считая размер группы от количества залогинившихся пользователей.

In [47]:
alpha = 0.05
quantity = 3

for i in [1, 2, 3]:
    
    p_value = z_test(funnel['A'][i], funnel['B'][i], funnel['A'][0], funnel['B'][0])
    
    print(f'Событие {funnel["event_name"][i]}: p-значение: {p_value}')
    print('Уровень значимости:', alpha / quantity)
    
    if p_value < alpha / quantity:
        print('Отвергаем нулевую гипотезу')
    else:
        print('Не получилось отвергнуть нулевую гипотезу')
    print('\n')
    
    quantity -= 1
Событие product_page: p-значение: 8.195976000324734e-06
Уровень значимости: 0.016666666666666666
Отвергаем нулевую гипотезу


Событие purchase: p-значение: 0.04864766695042433
Уровень значимости: 0.025
Не получилось отвергнуть нулевую гипотезу


Событие product_cart: p-значение: 0.2215941567364419
Уровень значимости: 0.05
Не получилось отвергнуть нулевую гипотезу


В первом сравнении удалось отвергнуть нулевую гипотезу. Конверсия в просмотр страницы продукта в группах А и Б не одинакова.

Во втором и третьем сравнении нулевую гипотезу не отвергаем. Нет статистически значимой разницы между конверсиями групп А и Б в просмотр корзины и в покупку.

4.1 вывод¶

  1. Для сравнения конверсий в группах А и Б проведен тест пропорций для каждого этапа воронки.

  2. Принят уровень alpha 0,05 и с учетом множественности сравнений применена поправка Холма.

  3. Конверсия в просмотр страницы продукта в группах А и Б не одинакова.

  4. Конверсия в в просмотр корзины и в покупку в группах А и Б одинакова, нет статистически значимой разницы.

назад в оглавление

5 Итог¶

  1. Соответствие данных техническому заданию:
  • + общее количество пользователей больше 6000,
  • + даты начала и окончания набора в тест соответствуют,
  • + аудитория — 15% пользователей из Европы,
  • - окончание совершения событий за несколько дней до полного лайфтайма последней когорты,
  • +/- распределение пользователей по группам не вполне равномерное,
  • - по первоначальному отбору в тест попало 350 пользователей из других регионов — 5% датасета,
  • - 45% пользователей в датасете не совершали никаких событий.
  1. Замечания к проведению теста:
  • тест проведен в дни перед Новым годом, шум от влияния предпраздничого поведения покупателей может значительно исказить результаты,
  • есть наложение на период теста маркетинговой акции,
  • распределение зарегистрированных пользователей по группам более или менее равномерно и составляет необходимые 6000, однако распределение хоть единожды залогинившихся пользователей составляет 1 к 3 и значительно меньше 6000.
  1. Календарное поведение групп:
  • у группы Б значительно лучше показатели по количеству событий в первую неделю эксперимента, то есть можно отметить какое-то влияние теста на поведение пользователей,
  • однако группа А показывает резкий и устойчивый рост количества регистраций и количества событий на вторую неделю эксперимента,
  • чего не происходит в группе Б, она остается без резких изменений внутри периода.
  1. Возможное объяснение: в обычное время тестируемая система положительно влияет на поведение пользователей (в частности на количество совершаемых событий), но в предпраздничные дни, наоборот, мешает (иначе группа Б тоже показала бы резкий рост с 14 декабря).
  1. Другие показатели:
  • характер лайфтайма в обеих группах совпадает, количество событий в первый день значительно превышает все остальные дни, 90% событий совершается в первые 9 дней.
  • пользователи группы Б немного чаще заходят с мобильных устройств, чем со стационарных,
  • пользователи группы Б немного чаще логинятся, чем просматривают страницы продуктов или совершают покупки.
  • чуть чаще (1%) пользователи группы Б совершают дешевые покупки (4.99), чем на большую сумму.
  • среднее и медианное количество событий на пользователя в группе А выше.
  1. Показатели конверсии в группе А значительно лучше на этапе просмотра страницы продукта и несколько лучше на этапе просмотра корзины и этапе покупки, что подтверждается статистическим тестом.

Результат: ожидаемый результат улучшения каждой из метрик в группе Б не менее, чем на 10% не достигнут.

Рекомендации:

  • обратить внимание на положительное влияние тестируемой системы на количество событий, совершаемых пользователями,
  • обратить внимание на возможное влияние на удобство использования тестируемой системы с мобильных устройств.

назад в оглавление